@occasiolabs/occasio 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/bin/occasio-mcp.js +5 -0
- package/bin/occasio.js +2 -0
- package/bin/supervisor/README.md +90 -0
- package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
- package/bin/supervisor/install-windows-task.ps1 +48 -0
- package/bin/supervisor/occasio.service +18 -0
- package/docs/AUDIT.md +120 -0
- package/docs/attest_verify.py +283 -0
- package/docs/audit_walker.py +65 -0
- package/docs/canonicalize.py +99 -0
- package/docs/compliance-mapping.md +93 -0
- package/docs/demos/mcp-block.md +148 -0
- package/docs/edr-calibration.md +73 -0
- package/docs/edr-demo.md +83 -0
- package/docs/python-verifier.md +74 -0
- package/docs/reference-pipeline.md +140 -0
- package/package.json +69 -0
- package/policy-templates/dev-default.yml +84 -0
- package/policy-templates/finance.yml +61 -0
- package/policy-templates/strict.yml +49 -0
- package/schemas/agent-attestation-v1.json +190 -0
- package/schemas/occasio-policy.schema.json +99 -0
- package/spec/agent-attestation/v1/README.md +137 -0
- package/src/adapters/claude-code.js +518 -0
- package/src/adapters/cline.js +161 -0
- package/src/adapters/computer-use-cli.js +198 -0
- package/src/adapters/computer-use.js +227 -0
- package/src/analyzer.js +170 -0
- package/src/anomaly/cli.js +143 -0
- package/src/anomaly/detectors/deny-rate.js +84 -0
- package/src/anomaly/detectors/file-read-volume.js +109 -0
- package/src/anomaly/detectors/secret-redact-rate.js +107 -0
- package/src/anomaly/detectors/unknown-tool-input.js +83 -0
- package/src/anomaly/index.js +169 -0
- package/src/attest/canonicalize.js +97 -0
- package/src/attest/index.js +355 -0
- package/src/attest/run-slice.js +57 -0
- package/src/attest/sign.js +186 -0
- package/src/attest/verify.js +192 -0
- package/src/audit/errors.js +21 -0
- package/src/audit/input-normalizer.js +121 -0
- package/src/audit/jsonl-auditor.js +178 -0
- package/src/audit/verifier.js +152 -0
- package/src/baseline.js +507 -0
- package/src/boundary.js +238 -0
- package/src/budget.js +42 -0
- package/src/classifier.js +115 -0
- package/src/context-budget.js +77 -0
- package/src/core/boundary-event.js +75 -0
- package/src/core/decision.js +61 -0
- package/src/core/pipeline.js +66 -0
- package/src/core/tool-names.js +105 -0
- package/src/dashboard.js +892 -0
- package/src/demo/README.md +31 -0
- package/src/demo/anomalies-demo.js +211 -0
- package/src/demo/attest-demo.js +198 -0
- package/src/distiller.js +155 -0
- package/src/embeddings.json +72 -0
- package/src/executor/dispatcher.js +230 -0
- package/src/harness.js +817 -0
- package/src/index.js +1711 -0
- package/src/inspect.js +329 -0
- package/src/interceptor.js +1198 -0
- package/src/lao.js +185 -0
- package/src/lao_prep.py +119 -0
- package/src/ledger.js +209 -0
- package/src/mcp-experiment.js +140 -0
- package/src/mcp-normalize.js +139 -0
- package/src/mcp-server.js +320 -0
- package/src/outbound-policy.js +433 -0
- package/src/policy/built-in-classifiers.js +78 -0
- package/src/policy/doctor.js +226 -0
- package/src/policy/engine.js +339 -0
- package/src/policy/init.js +153 -0
- package/src/policy/loader.js +448 -0
- package/src/policy/rules-default.js +36 -0
- package/src/policy/shell-path.js +135 -0
- package/src/policy/show.js +196 -0
- package/src/policy/validate.js +310 -0
- package/src/preflight/cli.js +164 -0
- package/src/preflight/miner.js +329 -0
- package/src/proxy/agent-router.js +93 -0
- package/src/redteam.js +428 -0
- package/src/replay.js +446 -0
- package/src/report/index.js +224 -0
- package/src/runtime.js +595 -0
- package/src/scanner/index.js +49 -0
- package/src/selftest.js +192 -0
- package/src/session.js +36 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# `src/demo/` — production CLI demos
|
|
2
|
+
|
|
3
|
+
The files in this folder back the `occasio demo <name>` CLI subcommands. They are **production features**, not throwaway scaffolding: they ship in the npm package, are linked from the project README, and are the first-impression artifact for design partners, investors, and auditors evaluating the project.
|
|
4
|
+
|
|
5
|
+
## What "demo" means here
|
|
6
|
+
|
|
7
|
+
Each file in this folder implements one runnable CLI demo that exercises a full Occasio pipeline end-to-end against synthetic data, in seconds, with no external dependencies (no API key, no GitHub Actions, no network). The synthetic-data choice is deliberate:
|
|
8
|
+
|
|
9
|
+
- Demos never touch the user's real `~/.occasio/pipeline-events.jsonl` audit chain
|
|
10
|
+
- Demos do not require credentials or sign-in
|
|
11
|
+
- Demos exercise the same code paths the real CLIs use (`buildAttestation`, `runDetectors`, etc.) — they wrap the production primitives rather than re-implementing them
|
|
12
|
+
- A passing `occasio demo X` is also a smoke test for the underlying schiene
|
|
13
|
+
|
|
14
|
+
## Files
|
|
15
|
+
|
|
16
|
+
| File | CLI command | What it demonstrates |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `attest-demo.js` | `occasio demo attest` | Build → verify → check-run-preview for the attestation pipeline |
|
|
19
|
+
| `anomalies-demo.js` | `occasio demo anomalies` | Synthetic adversarial chain → all 4 EDR detectors fire |
|
|
20
|
+
|
|
21
|
+
## Naming convention
|
|
22
|
+
|
|
23
|
+
`demo/<feature>-demo.js` exports `run<Feature>DemoCli(args)` and is wired into the top-level CLI dispatcher (`src/index.js`) under `cmd === 'demo' && args[1] === '<feature>'`. The two-token CLI form (`occasio demo <feature>`) keeps these visible as a coherent demo surface in `occasio --help` without polluting the top-level command namespace.
|
|
24
|
+
|
|
25
|
+
If you need a CLI that operates against real user data (not synthetic), it does not belong here — put it directly in `src/<feature>/cli.js` next to the production primitives.
|
|
26
|
+
|
|
27
|
+
## Why not put these in `examples/`
|
|
28
|
+
|
|
29
|
+
`examples/` (sibling of `src/`) is for static example files users copy into their own setup — `policy.yml` templates, GitHub Action YAML snippets, JSONL fixtures. The contents of `examples/` are read by the user, not executed by Occasio.
|
|
30
|
+
|
|
31
|
+
`src/demo/` is for executable demos that ship as installable CLI commands. Different distribution path, different audience.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* demo/anomalies-demo.js — `occasio demo anomalies`
|
|
5
|
+
*
|
|
6
|
+
* Production CLI command, first-class user-facing feature. See
|
|
7
|
+
* src/demo/README.md for the folder convention. Builds a synthetic
|
|
8
|
+
* adversarial audit chain in a temp dir and runs all four anomaly
|
|
9
|
+
* detectors against it. Each detector is designed to fire on exactly
|
|
10
|
+
* one of the four written patterns:
|
|
11
|
+
*
|
|
12
|
+
* deny-rate pattern: 12 BLOCK rows in the recent window
|
|
13
|
+
* (vs. a quiet historical baseline)
|
|
14
|
+
* file-read-volume pattern: 60 distinct file paths read in window
|
|
15
|
+
* (vs. historical p95 ≈ 5)
|
|
16
|
+
* unknown-tool-input pattern: Bash invoked with novel `env` key
|
|
17
|
+
* (after 60 historical "Bash {command, cwd}" rows)
|
|
18
|
+
* secret-redact-rate pattern: 3 redacted-secret rows in window
|
|
19
|
+
* (vs. zero in 250 historical rows)
|
|
20
|
+
*
|
|
21
|
+
* Never touches ~/.occasio — uses os.tmpdir() throughout.
|
|
22
|
+
*
|
|
23
|
+
* Used by:
|
|
24
|
+
* - Smoke-testing the EDR layer end-to-end
|
|
25
|
+
* - First-time demo / "show me what would have fired in our last shift"
|
|
26
|
+
* - Reference for what a real adversarial-activity chain shape looks like
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
|
|
34
|
+
const { runDetectors, DEFAULT_WINDOW_MS } = require('../anomaly');
|
|
35
|
+
|
|
36
|
+
const C = {
|
|
37
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
38
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
39
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
40
|
+
c: s => `\x1b[36m${s}\x1b[0m`,
|
|
41
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
42
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const SEV_COLOR = { high: C.r, medium: C.y, low: C.c };
|
|
46
|
+
|
|
47
|
+
const GENESIS = '0'.repeat(64);
|
|
48
|
+
const RUN_ID = '12345678-1234-1234-1234-123456789abc';
|
|
49
|
+
|
|
50
|
+
function appendRow(file, prevHash, row) {
|
|
51
|
+
const withPrev = { ...row, prev_hash: prevHash };
|
|
52
|
+
const hash = crypto.createHash('sha256').update(JSON.stringify(withPrev), 'utf8').digest('hex');
|
|
53
|
+
const full = { ...withPrev, hash };
|
|
54
|
+
fs.appendFileSync(file, JSON.stringify(full) + '\n');
|
|
55
|
+
return hash;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the adversarial chain. Returns the file path + the "now" anchor we
|
|
60
|
+
* should evaluate against (cleaner than relying on wall clock).
|
|
61
|
+
*
|
|
62
|
+
* Timeline:
|
|
63
|
+
* t = NOW - 3h ...... NOW - 20m → historical baseline (quiet)
|
|
64
|
+
* t = NOW - 10m ..... NOW → window (the anomalies live here)
|
|
65
|
+
*/
|
|
66
|
+
function buildAdversarialChain(chainFile) {
|
|
67
|
+
fs.writeFileSync(chainFile, '');
|
|
68
|
+
const NOW = Date.parse('2026-05-14T16:00:00.000Z');
|
|
69
|
+
const tIso = (msAgo) => new Date(NOW - msAgo).toISOString();
|
|
70
|
+
let prev = GENESIS;
|
|
71
|
+
|
|
72
|
+
// ── Historical baseline ────────────────────────────────────────────────
|
|
73
|
+
// 300 quiet rows over 3h: tool reads against a small file pool, no BLOCKs,
|
|
74
|
+
// no redactions, all Bash calls use only { command, cwd } keys.
|
|
75
|
+
const baselinePaths = ['a.js', 'b.js', 'c.js', 'd.js', 'e.js'];
|
|
76
|
+
for (let i = 0; i < 300; i++) {
|
|
77
|
+
const msAgo = 3 * 60 * 60 * 1000 - i * Math.floor((3 * 60 * 60 * 1000 - 20 * 60 * 1000) / 300);
|
|
78
|
+
if (i % 3 === 0) {
|
|
79
|
+
prev = appendRow(chainFile, prev, {
|
|
80
|
+
ts: tIso(msAgo),
|
|
81
|
+
kind: 'tool_call', tool_name: 'Read', action: 'PASS',
|
|
82
|
+
tool_inputs: { path: 'src/' + baselinePaths[i % baselinePaths.length] },
|
|
83
|
+
run_id: RUN_ID,
|
|
84
|
+
});
|
|
85
|
+
} else if (i % 3 === 1) {
|
|
86
|
+
prev = appendRow(chainFile, prev, {
|
|
87
|
+
ts: tIso(msAgo),
|
|
88
|
+
kind: 'tool_call', tool_name: 'Grep', action: 'PASS',
|
|
89
|
+
tool_inputs: { pattern: 'TODO', path: 'src/' },
|
|
90
|
+
run_id: RUN_ID,
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
prev = appendRow(chainFile, prev, {
|
|
94
|
+
ts: tIso(msAgo),
|
|
95
|
+
kind: 'tool_call', tool_name: 'Bash', action: 'PASS',
|
|
96
|
+
tool_inputs: { command: 'ls', cwd: '/workspace' },
|
|
97
|
+
run_id: RUN_ID,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Window: adversarial activity (last ~10 minutes) ────────────────────
|
|
103
|
+
// Each block below intentionally triggers one of the four detectors.
|
|
104
|
+
|
|
105
|
+
// (1) Deny-rate spike — 12 rapid BLOCKs on sensitive paths
|
|
106
|
+
for (let i = 0; i < 12; i++) {
|
|
107
|
+
prev = appendRow(chainFile, prev, {
|
|
108
|
+
ts: tIso(10 * 60_000 - i * 30_000),
|
|
109
|
+
kind: 'tool_call', tool_name: 'Read', action: 'BLOCK',
|
|
110
|
+
tool_inputs: { path: '/home/user/.ssh/id_rsa' },
|
|
111
|
+
reason: 'path-denied',
|
|
112
|
+
run_id: RUN_ID,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// (2) File-read-volume burst — 60 distinct paths, all new
|
|
117
|
+
for (let i = 0; i < 60; i++) {
|
|
118
|
+
prev = appendRow(chainFile, prev, {
|
|
119
|
+
ts: tIso(8 * 60_000 - i * 5_000),
|
|
120
|
+
kind: 'tool_call', tool_name: 'Read', action: 'PASS',
|
|
121
|
+
tool_inputs: { path: `recon/discovered-${i}.cfg` },
|
|
122
|
+
run_id: RUN_ID,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// (3) Unknown-tool-input — Bash invoked with previously-unseen `env` key
|
|
127
|
+
prev = appendRow(chainFile, prev, {
|
|
128
|
+
ts: tIso(5 * 60_000),
|
|
129
|
+
kind: 'tool_call', tool_name: 'Bash', action: 'PASS',
|
|
130
|
+
tool_inputs: { command: 'curl https://example.com', cwd: '/workspace',
|
|
131
|
+
env: { LD_PRELOAD: '/tmp/x.so' } },
|
|
132
|
+
run_id: RUN_ID,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// (4) Secret-redact-rate — 3 redaction events
|
|
136
|
+
for (let i = 0; i < 3; i++) {
|
|
137
|
+
prev = appendRow(chainFile, prev, {
|
|
138
|
+
ts: tIso(3 * 60_000 - i * 30_000),
|
|
139
|
+
kind: 'tool_call', tool_name: 'Grep', action: 'TRANSFORM',
|
|
140
|
+
tool_inputs: { pattern: 'sk-ant-', path: 'src/' },
|
|
141
|
+
secrets_redacted: 1,
|
|
142
|
+
run_id: RUN_ID,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { chainFile, nowMs: NOW };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runAnomaliesDemoCli(_args = []) {
|
|
150
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lf-demo-anomalies-'));
|
|
151
|
+
const chainFile = path.join(tmpDir, 'pipeline-events.jsonl');
|
|
152
|
+
|
|
153
|
+
process.stdout.write(`${C.b('occasio demo anomalies')}\n`);
|
|
154
|
+
process.stdout.write(`${C.d(' scratch dir: ' + tmpDir)}\n\n`);
|
|
155
|
+
|
|
156
|
+
// ── Step 1 ─────────────────────────────────────────────────────────────
|
|
157
|
+
process.stdout.write(`${C.b('1.')} Building synthetic adversarial chain ${C.d('(300 quiet baseline rows + 4 distinct anomaly patterns in last 10 min)')}\n`);
|
|
158
|
+
const { nowMs } = buildAdversarialChain(chainFile);
|
|
159
|
+
const stat = fs.statSync(chainFile);
|
|
160
|
+
process.stdout.write(` ${C.g('✓')} ${C.d(chainFile + ' (' + stat.size + ' bytes)')}\n\n`);
|
|
161
|
+
|
|
162
|
+
// ── Step 2 ─────────────────────────────────────────────────────────────
|
|
163
|
+
process.stdout.write(`${C.b('2.')} Running detectors over the last ${C.c('15 minutes')} ${C.d('(window vs. historical baseline)')}\n\n`);
|
|
164
|
+
const result = runDetectors({
|
|
165
|
+
chainFile,
|
|
166
|
+
windowMs: DEFAULT_WINDOW_MS,
|
|
167
|
+
now: nowMs,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
process.stdout.write(` ${C.d('window: ' + result.window_start + ' → ' + result.window_end)}\n`);
|
|
171
|
+
process.stdout.write(` ${C.d(result.window_rows + ' rows in window, ' + result.history_rows + ' historical)')}\n\n`);
|
|
172
|
+
|
|
173
|
+
if (result.alerts.length === 0) {
|
|
174
|
+
process.stderr.write(` ${C.r('✗ no alerts fired — detectors are silent on activity that should trigger them')}\n`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const a of result.alerts) {
|
|
179
|
+
const color = SEV_COLOR[a.severity] || C.d;
|
|
180
|
+
process.stdout.write(` ${color('[' + a.severity.toUpperCase() + ']')} ${C.b(a.detector_id)}\n`);
|
|
181
|
+
process.stdout.write(` ${a.message}\n`);
|
|
182
|
+
if (a.rows_implicated && a.rows_implicated.length) {
|
|
183
|
+
process.stdout.write(` ${C.d('implicated: ' + a.rows_implicated[0].slice(0, 16) +
|
|
184
|
+
'… (+' + (a.rows_implicated.length - 1) + ' more)')}\n`);
|
|
185
|
+
}
|
|
186
|
+
process.stdout.write('\n');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Step 3 ─────────────────────────────────────────────────────────────
|
|
190
|
+
process.stdout.write(`${C.b('3.')} Verifying all four built-in detectors fired\n`);
|
|
191
|
+
const fired = new Set(result.alerts.map(a => a.detector_id));
|
|
192
|
+
const expected = ['deny-rate', 'file-read-volume', 'unknown-tool-input', 'secret-redact-rate'];
|
|
193
|
+
let allFired = true;
|
|
194
|
+
for (const id of expected) {
|
|
195
|
+
if (fired.has(id)) {
|
|
196
|
+
process.stdout.write(` ${C.g('✓')} ${id}\n`);
|
|
197
|
+
} else {
|
|
198
|
+
process.stdout.write(` ${C.r('✗')} ${id} ${C.d('— did NOT fire on its trigger pattern (regression!)')}\n`);
|
|
199
|
+
allFired = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
process.stdout.write('\n');
|
|
203
|
+
|
|
204
|
+
if (!allFired) return 1;
|
|
205
|
+
process.stdout.write(`${C.g('✓')} EDR layer end-to-end OK ${C.d('(scratch chain preserved at ' + tmpDir + ')')}\n`);
|
|
206
|
+
process.stdout.write(`\n${C.d('Re-run against the same chain interactively:')}\n`);
|
|
207
|
+
process.stdout.write(` ${C.c('occasio anomalies --chain ' + chainFile + ' --since "' + new Date(nowMs).toISOString() + '"')}\n\n`);
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { runAnomaliesDemoCli, buildAdversarialChain };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* demo/attest-demo.js — `occasio demo attest`
|
|
5
|
+
*
|
|
6
|
+
* Production CLI command, first-class user-facing feature. The `demo/`
|
|
7
|
+
* folder houses CLIs that exercise a full pipeline against synthetic
|
|
8
|
+
* data so users can see the system end-to-end in seconds without
|
|
9
|
+
* touching their real ~/.occasio chain. These are demos in the
|
|
10
|
+
* sense of "demonstrations" — they are not throwaway scaffolding,
|
|
11
|
+
* they ship in the npm package and are documented in the README.
|
|
12
|
+
*
|
|
13
|
+
* End-to-end demo of the attestation pipeline, locally, without GitHub
|
|
14
|
+
* Actions or external services. Runs against a synthetic audit chain so
|
|
15
|
+
* it never touches the user's real ~/.occasio/pipeline-events.jsonl.
|
|
16
|
+
*
|
|
17
|
+
* Flow demonstrated:
|
|
18
|
+
* 1. Build a hash-chained scratch audit chain (3 PASS + 1 BLOCK + 1
|
|
19
|
+
* TRANSFORM with redacted secret + 1 policy_loaded row)
|
|
20
|
+
* 2. Verify the chain integrity (mirrors `occasio audit verify`)
|
|
21
|
+
* 3. Build an unsigned attestation for the synthetic run_id
|
|
22
|
+
* 4. Verify the predicate ↔ attestation byte-equivalence (canonical
|
|
23
|
+
* JSON round-trip) without invoking Sigstore — the test harness
|
|
24
|
+
* validates the cryptographic path elsewhere
|
|
25
|
+
* 5. Print what the GitHub Check Run summary WOULD look like
|
|
26
|
+
* 6. Render the same data as the View-Evidence page would render it
|
|
27
|
+
*
|
|
28
|
+
* Used by:
|
|
29
|
+
* - First-time demo / "show me what this does end-to-end"
|
|
30
|
+
* - Smoke test in CI for the full predicate happy path
|
|
31
|
+
* - Reference Pipeline doc (docs/reference-pipeline.md)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const crypto = require('crypto');
|
|
38
|
+
|
|
39
|
+
const { buildAttestation } = require('../attest');
|
|
40
|
+
const { verifyFile } = require('../audit/verifier');
|
|
41
|
+
const { canonicalize } = require('../attest/canonicalize');
|
|
42
|
+
const { buildSummary } = require('../../integrations/attest-action/scripts/post-check');
|
|
43
|
+
|
|
44
|
+
const C = {
|
|
45
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
46
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
47
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
48
|
+
c: s => `\x1b[36m${s}\x1b[0m`,
|
|
49
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
50
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const GENESIS = '0'.repeat(64);
|
|
54
|
+
|
|
55
|
+
function appendRow(file, prevHash, row) {
|
|
56
|
+
const withPrev = { ...row, prev_hash: prevHash };
|
|
57
|
+
const hash = crypto.createHash('sha256').update(JSON.stringify(withPrev), 'utf8').digest('hex');
|
|
58
|
+
const full = { ...withPrev, hash };
|
|
59
|
+
fs.appendFileSync(file, JSON.stringify(full) + '\n');
|
|
60
|
+
return { hash, full };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildSyntheticChain(chainFile, policyFile) {
|
|
64
|
+
// Reset the chain file fresh.
|
|
65
|
+
fs.writeFileSync(chainFile, '');
|
|
66
|
+
fs.writeFileSync(policyFile, [
|
|
67
|
+
'version: 1',
|
|
68
|
+
'deny_paths:',
|
|
69
|
+
' - "**/.env"',
|
|
70
|
+
' - "**/.ssh/**"',
|
|
71
|
+
'deny_patterns:',
|
|
72
|
+
' api_key: "(?i)api[_-]?key\\\\s*[:=]\\\\s*\\\\S+"',
|
|
73
|
+
'block_secrets_in_tool_results: true',
|
|
74
|
+
'',
|
|
75
|
+
].join('\n'));
|
|
76
|
+
|
|
77
|
+
const RUN = crypto.randomBytes(16).toString('hex');
|
|
78
|
+
const RUN_ID = `${RUN.slice(0,8)}-${RUN.slice(8,12)}-${RUN.slice(12,16)}-${RUN.slice(16,20)}-${RUN.slice(20,32)}`;
|
|
79
|
+
const policyHash = crypto.createHash('sha256').update(fs.readFileSync(policyFile)).digest('hex');
|
|
80
|
+
|
|
81
|
+
let prev = GENESIS;
|
|
82
|
+
const ts = (s) => new Date(Date.UTC(2026, 0, 1, 12, 0, s)).toISOString();
|
|
83
|
+
|
|
84
|
+
// policy_loaded
|
|
85
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
86
|
+
ts: ts(0), event_id: crypto.randomUUID(),
|
|
87
|
+
run_id: RUN_ID, agent: 'occasio',
|
|
88
|
+
kind: 'policy_loaded', tool_name: 'policy_loaded', action: 'INFO',
|
|
89
|
+
tool_inputs: { policy_hash: policyHash, policy_path: policyFile, version: 1 },
|
|
90
|
+
policy_source: 'user', reason: 'policy-loaded',
|
|
91
|
+
}));
|
|
92
|
+
// PASS read
|
|
93
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
94
|
+
ts: ts(5), event_id: crypto.randomUUID(),
|
|
95
|
+
run_id: RUN_ID, agent: 'claude-code',
|
|
96
|
+
kind: 'tool_call', tool_name: 'Read', action: 'PASS',
|
|
97
|
+
tool_inputs: { path: 'src/index.js' },
|
|
98
|
+
}));
|
|
99
|
+
// BLOCK on denied path
|
|
100
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
101
|
+
ts: ts(10), event_id: crypto.randomUUID(),
|
|
102
|
+
run_id: RUN_ID, agent: 'claude-code',
|
|
103
|
+
kind: 'tool_call', tool_name: 'Read', action: 'BLOCK',
|
|
104
|
+
tool_inputs: { path: '/home/user/.ssh/id_rsa' },
|
|
105
|
+
reason: 'path-denied',
|
|
106
|
+
}));
|
|
107
|
+
// TRANSFORM with redacted secret in result
|
|
108
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
109
|
+
ts: ts(15), event_id: crypto.randomUUID(),
|
|
110
|
+
run_id: RUN_ID, agent: 'claude-code',
|
|
111
|
+
kind: 'tool_call', tool_name: 'Grep', action: 'TRANSFORM',
|
|
112
|
+
tool_inputs: { pattern: 'TODO', path: 'src/' },
|
|
113
|
+
secrets_redacted: 1,
|
|
114
|
+
}));
|
|
115
|
+
// LOCAL Glob
|
|
116
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
117
|
+
ts: ts(20), event_id: crypto.randomUUID(),
|
|
118
|
+
run_id: RUN_ID, agent: 'claude-code',
|
|
119
|
+
kind: 'tool_call', tool_name: 'Glob', action: 'LOCAL',
|
|
120
|
+
tool_inputs: { pattern: '**/*.js' },
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
return RUN_ID;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runAttestDemoCli(_args = []) {
|
|
127
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lf-demo-attest-'));
|
|
128
|
+
const chainFile = path.join(tmpDir, 'pipeline-events.jsonl');
|
|
129
|
+
const policyFile = path.join(tmpDir, 'policy.yml');
|
|
130
|
+
const outFile = path.join(tmpDir, 'occasio-attestation.json');
|
|
131
|
+
|
|
132
|
+
process.stdout.write(`${C.b('occasio demo attest')}\n`);
|
|
133
|
+
process.stdout.write(`${C.d(' scratch dir: ' + tmpDir)}\n\n`);
|
|
134
|
+
|
|
135
|
+
// ── Step 1 ─────────────────────────────────────────────────────────────
|
|
136
|
+
process.stdout.write(`${C.b('1.')} Building synthetic audit chain ${C.d('(5 rows: policy_loaded + Read PASS + Read BLOCK + Grep TRANSFORM/redacted + Glob LOCAL)')}\n`);
|
|
137
|
+
const runId = buildSyntheticChain(chainFile, policyFile);
|
|
138
|
+
process.stdout.write(` ${C.g('✓')} run_id: ${C.c(runId)}\n\n`);
|
|
139
|
+
|
|
140
|
+
// ── Step 2 ─────────────────────────────────────────────────────────────
|
|
141
|
+
process.stdout.write(`${C.b('2.')} Verifying audit-chain integrity ${C.d('(SHA-256-walk every prev_hash → hash link)')}\n`);
|
|
142
|
+
const ver = verifyFile(chainFile);
|
|
143
|
+
if (!ver.ok) {
|
|
144
|
+
process.stderr.write(` ${C.r('✗')} chain broken — this should not happen on a synthetic chain\n`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
process.stdout.write(` ${C.g('✓')} chain integrity OK ${C.d('(' + ver.chained + ' rows)')}\n\n`);
|
|
148
|
+
|
|
149
|
+
// ── Step 3 ─────────────────────────────────────────────────────────────
|
|
150
|
+
process.stdout.write(`${C.b('3.')} Building behavioral attestation ${C.d('(unsigned — signing requires Sigstore + GitHub OIDC)')}\n`);
|
|
151
|
+
const att = buildAttestation({ runId, logFile: chainFile, policyFile });
|
|
152
|
+
if (!att) {
|
|
153
|
+
process.stderr.write(` ${C.r('✗')} buildAttestation returned null\n`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
fs.writeFileSync(outFile, JSON.stringify(att, null, 2));
|
|
157
|
+
process.stdout.write(` ${C.g('✓')} ${C.d(outFile)}\n`);
|
|
158
|
+
process.stdout.write(` ${C.d('tool_calls: ' + att.execution_summary.tool_calls +
|
|
159
|
+
' blocked: ' + att.execution_summary.blocked +
|
|
160
|
+
' transformed: ' + att.execution_summary.transformed +
|
|
161
|
+
' secrets_redacted: ' + att.execution_summary.secrets_redacted)}\n`);
|
|
162
|
+
process.stdout.write(` ${C.d('chain: ' + att.audit_chain.first_hash.slice(0,12) + '…' +
|
|
163
|
+
att.audit_chain.last_hash.slice(0,12) + ' (' + att.audit_chain.event_count + ' events)')}\n`);
|
|
164
|
+
process.stdout.write(` ${C.d('policy: ' + att.policy.source + ' hash=' +
|
|
165
|
+
att.policy.file_hash.slice(0,16) + '…')}\n\n`);
|
|
166
|
+
|
|
167
|
+
// ── Step 4 ─────────────────────────────────────────────────────────────
|
|
168
|
+
process.stdout.write(`${C.b('4.')} Canonical-JSON byte-equivalence ${C.d('(predicate parsed back from disk vs in-memory attestation)')}\n`);
|
|
169
|
+
const reparsed = JSON.parse(fs.readFileSync(outFile, 'utf8'));
|
|
170
|
+
const { signature: _omit1, ...expected } = att;
|
|
171
|
+
const { signature: _omit2, ...observed } = reparsed;
|
|
172
|
+
if (canonicalize(expected) !== canonicalize(observed)) {
|
|
173
|
+
process.stderr.write(` ${C.r('✗')} canonical bytes diverged — this is a bug\n`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
process.stdout.write(` ${C.g('✓')} canonical round-trip stable\n\n`);
|
|
177
|
+
|
|
178
|
+
// ── Step 5 ─────────────────────────────────────────────────────────────
|
|
179
|
+
process.stdout.write(`${C.b('5.')} GitHub Check Run preview ${C.d('(this is what reviewers see on the PR)')}\n`);
|
|
180
|
+
const { title, summary } = buildSummary(att, '');
|
|
181
|
+
process.stdout.write(`\n ${C.b('Title:')} ${title}\n\n`);
|
|
182
|
+
for (const line of summary.split('\n').slice(0, 12)) {
|
|
183
|
+
process.stdout.write(` ${line}\n`);
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(` ${C.d('… (table truncated; full body in the live Check Run)')}\n\n`);
|
|
186
|
+
|
|
187
|
+
// ── Step 6 ─────────────────────────────────────────────────────────────
|
|
188
|
+
process.stdout.write(`${C.b('6.')} Next steps for a live deployment\n`);
|
|
189
|
+
process.stdout.write(` - Add ${C.c('.github/workflows/attest-on-pr.yml')} from ${C.d('docs/reference-pipeline.md')}\n`);
|
|
190
|
+
process.stdout.write(` - Grant the workflow ${C.c('id-token: write')} + ${C.c('checks: write')} permissions\n`);
|
|
191
|
+
process.stdout.write(` - Push a PR; the Action will sign via Sigstore keyless and post a Check Run\n`);
|
|
192
|
+
process.stdout.write(` - Auditors verify offline: ${C.c('occasio attest verify <file>')}\n\n`);
|
|
193
|
+
process.stdout.write(`${C.d('Scratch artifacts kept at ' + tmpDir + ' for inspection.')}\n`);
|
|
194
|
+
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { runAttestDemoCli, buildSyntheticChain };
|
package/src/distiller.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* distiller.js — Output distillation for intercepted tool results.
|
|
5
|
+
*
|
|
6
|
+
* Applies conservative line-count limits to high-volume, low-risk, read-only
|
|
7
|
+
* command output before it re-enters the model as a tool_result.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* grep / rg → clip at GREP_MAX_LINES
|
|
11
|
+
* find → clip at FIND_MAX_LINES
|
|
12
|
+
* ls / dir / gci → clip at LS_MAX_LINES
|
|
13
|
+
* git log → clip at GIT_LOG_MAX_LINES
|
|
14
|
+
* test runners → smart extraction: failures + summary, clip at TEST_MAX_LINES
|
|
15
|
+
*
|
|
16
|
+
* All other commands pass through unchanged.
|
|
17
|
+
* Short output (below threshold) passes through unchanged.
|
|
18
|
+
*
|
|
19
|
+
* result.rawContent is set to the original string when output was distilled,
|
|
20
|
+
* so callers can persist it for replay/debugging.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const GREP_MAX_LINES = 50;
|
|
24
|
+
const FIND_MAX_LINES = 100;
|
|
25
|
+
const LS_MAX_LINES = 100;
|
|
26
|
+
const GIT_LOG_MAX_LINES = 100;
|
|
27
|
+
const TEST_MAX_LINES = 100;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Map a command string to its distillation category.
|
|
31
|
+
* Returns null when no rule applies.
|
|
32
|
+
*/
|
|
33
|
+
function classifyCmd(cmd) {
|
|
34
|
+
if (!cmd) return null;
|
|
35
|
+
const parts = cmd.trim().split(/\s+/);
|
|
36
|
+
const head = parts[0].toLowerCase();
|
|
37
|
+
|
|
38
|
+
if (head === 'grep' || head === 'rg') return 'grep';
|
|
39
|
+
if (head === 'find') return 'find';
|
|
40
|
+
if (['ls','eza','exa','lsd','dir'].includes(head)) return 'ls';
|
|
41
|
+
if (/^get-childitem$/i.test(parts[0])) return 'ls';
|
|
42
|
+
if (head === 'git' && parts[1] === 'log') return 'git-log';
|
|
43
|
+
|
|
44
|
+
// Test runners
|
|
45
|
+
if (['jest','vitest','pytest','py.test'].includes(head)) return 'test';
|
|
46
|
+
if (head === 'cargo' && parts[1] === 'test') return 'test';
|
|
47
|
+
if (head === 'go' && parts[1] === 'test') return 'test';
|
|
48
|
+
if (['npm','npx','yarn','pnpm'].includes(head)) {
|
|
49
|
+
const sub = (parts[1] || '').toLowerCase();
|
|
50
|
+
if (sub === 'test' || sub === 'jest' || sub === 'vitest' || sub === 'run') {
|
|
51
|
+
// npm run test / yarn run test etc. — only classify as test when sub-cmd is test-like
|
|
52
|
+
if (sub === 'run') {
|
|
53
|
+
const subsub = (parts[2] || '').toLowerCase();
|
|
54
|
+
if (subsub === 'test' || subsub === 'jest' || subsub === 'vitest') return 'test';
|
|
55
|
+
} else {
|
|
56
|
+
return 'test';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Distill a single tool-result output string.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} cmd The command that produced the output
|
|
68
|
+
* @param {string} output The raw output string
|
|
69
|
+
* @returns {{
|
|
70
|
+
* content: string, // what to send to Anthropic
|
|
71
|
+
* distilled: boolean,
|
|
72
|
+
* savedTokens: number, // estimated tokens saved (bytes/4)
|
|
73
|
+
* label: string, // human-readable label for logs
|
|
74
|
+
* rawBytes: number, // original byte count (for ledger)
|
|
75
|
+
* rawContent: string | null, // original output when distilled; null when not distilled
|
|
76
|
+
* }}
|
|
77
|
+
*/
|
|
78
|
+
function distill(cmd, output) {
|
|
79
|
+
const rawBytes = Buffer.byteLength(output || '', 'utf8');
|
|
80
|
+
const none = { content: output, distilled: false, savedTokens: 0, label: '', rawBytes, rawContent: null };
|
|
81
|
+
|
|
82
|
+
if (!output || !cmd) return none;
|
|
83
|
+
|
|
84
|
+
const category = classifyCmd(cmd);
|
|
85
|
+
if (!category) return none;
|
|
86
|
+
|
|
87
|
+
// Test output uses smart failure-preserving extraction
|
|
88
|
+
if (category === 'test') return distillTestOutput(output, rawBytes, cmd);
|
|
89
|
+
|
|
90
|
+
const lines = output.split('\n');
|
|
91
|
+
|
|
92
|
+
let maxLines;
|
|
93
|
+
let unitLabel;
|
|
94
|
+
switch (category) {
|
|
95
|
+
case 'grep': maxLines = GREP_MAX_LINES; unitLabel = 'match lines'; break;
|
|
96
|
+
case 'find': maxLines = FIND_MAX_LINES; unitLabel = 'paths'; break;
|
|
97
|
+
case 'ls': maxLines = LS_MAX_LINES; unitLabel = 'entries'; break;
|
|
98
|
+
case 'git-log': maxLines = GIT_LOG_MAX_LINES; unitLabel = 'log lines'; break;
|
|
99
|
+
default: return none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (lines.length <= maxLines) return none;
|
|
103
|
+
|
|
104
|
+
const nonEmpty = lines.filter(l => l.trim()).length;
|
|
105
|
+
const content = lines.slice(0, maxLines).join('\n')
|
|
106
|
+
+ `\n[Occasio: ${nonEmpty} ${unitLabel} total — showing first ${maxLines}. Full output not re-sent to model.]`;
|
|
107
|
+
|
|
108
|
+
const savedBytes = Math.max(0, rawBytes - Buffer.byteLength(content, 'utf8'));
|
|
109
|
+
const savedTokens = Math.ceil(savedBytes / 4);
|
|
110
|
+
const label = `${category} clipped ${nonEmpty}→${maxLines} ${unitLabel}`;
|
|
111
|
+
|
|
112
|
+
return { content, distilled: true, savedTokens, label, rawBytes, rawContent: output };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Regex matching lines that likely contain test failures or errors.
|
|
116
|
+
const FAIL_RE = /\b(FAIL|FAILED|ERROR|error:|✗|×|AssertionError|not ok|ERRORED)\b/;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Smart distillation for test-runner output.
|
|
120
|
+
* Keeps all failure-related lines (plus 1 line of context each side) and the
|
|
121
|
+
* last 15 lines (usually the summary). Clips total to TEST_MAX_LINES.
|
|
122
|
+
*/
|
|
123
|
+
function distillTestOutput(output, rawBytes, cmd) {
|
|
124
|
+
const lines = output.split('\n');
|
|
125
|
+
const none = { content: output, distilled: false, savedTokens: 0, label: '', rawBytes, rawContent: null };
|
|
126
|
+
if (lines.length <= TEST_MAX_LINES) return none;
|
|
127
|
+
|
|
128
|
+
// Collect indices worth preserving
|
|
129
|
+
const keepIdx = new Set();
|
|
130
|
+
|
|
131
|
+
// Failure context lines
|
|
132
|
+
lines.forEach((l, i) => {
|
|
133
|
+
if (FAIL_RE.test(l)) {
|
|
134
|
+
if (i > 0) keepIdx.add(i - 1);
|
|
135
|
+
keepIdx.add(i);
|
|
136
|
+
if (i < lines.length - 1) keepIdx.add(i + 1);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Always keep the last 15 lines (pass/fail summary)
|
|
141
|
+
for (let i = Math.max(0, lines.length - 15); i < lines.length; i++) keepIdx.add(i);
|
|
142
|
+
|
|
143
|
+
const chosen = [...keepIdx].sort((a, b) => a - b).map(i => lines[i]);
|
|
144
|
+
const totalNonEmpty = lines.filter(l => l.trim()).length;
|
|
145
|
+
const note = `[Occasio: test output ${totalNonEmpty} lines — showing ${chosen.length} failure/summary lines. Full output saved for inspection: occasio distill]`;
|
|
146
|
+
const content = chosen.join('\n') + '\n' + note;
|
|
147
|
+
|
|
148
|
+
const savedBytes = Math.max(0, rawBytes - Buffer.byteLength(content, 'utf8'));
|
|
149
|
+
const savedTokens = Math.ceil(savedBytes / 4);
|
|
150
|
+
const label = `test clipped ${totalNonEmpty}→${chosen.length} lines (failures+summary)`;
|
|
151
|
+
|
|
152
|
+
return { content, distilled: true, savedTokens, label, rawBytes, rawContent: output };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { distill, classifyCmd, GREP_MAX_LINES, FIND_MAX_LINES, LS_MAX_LINES, GIT_LOG_MAX_LINES, TEST_MAX_LINES, FAIL_RE };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"description": "Class centroids for local routing decisions. Edit to extend without touching code.",
|
|
4
|
+
|
|
5
|
+
"always_local": [
|
|
6
|
+
"grep", "rg", "fd", "fzf",
|
|
7
|
+
"find", "locate",
|
|
8
|
+
"ls", "eza", "exa", "lsd", "tree",
|
|
9
|
+
"cat", "bat", "less", "more", "head", "tail",
|
|
10
|
+
"wc", "nl", "sort", "uniq", "cut", "tr",
|
|
11
|
+
"echo", "pwd", "which", "whereis", "where", "type",
|
|
12
|
+
"stat", "file", "du", "df", "dir",
|
|
13
|
+
"diff", "delta",
|
|
14
|
+
"date", "uptime", "uname",
|
|
15
|
+
"ps", "top", "htop"
|
|
16
|
+
],
|
|
17
|
+
"_removed_from_always_local": {
|
|
18
|
+
"env": "may expose API keys/tokens — output bypasses outbound secret scanner when intercepted",
|
|
19
|
+
"printenv": "same reason as env"
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
"never_local": [
|
|
23
|
+
"rm", "rmdir", "del",
|
|
24
|
+
"npm", "npx", "pip", "pip3", "yarn", "pnpm", "cargo", "gem", "go", "mvn", "gradle",
|
|
25
|
+
"curl", "wget", "fetch",
|
|
26
|
+
"ssh", "scp", "sftp", "rsync",
|
|
27
|
+
"docker", "kubectl", "helm", "terraform", "ansible",
|
|
28
|
+
"python", "python3", "node", "ruby", "java", "perl", "php", "lua",
|
|
29
|
+
"make", "cmake", "gcc", "g++", "clang", "rustc", "javac",
|
|
30
|
+
"touch", "mkdir", "cp", "mv",
|
|
31
|
+
"chmod", "chown", "chgrp",
|
|
32
|
+
"kill", "pkill", "killall",
|
|
33
|
+
"tee", "dd",
|
|
34
|
+
"apt", "apt-get", "brew", "pacman", "yum", "dnf"
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
"git_safe_subcommands": [
|
|
38
|
+
"log", "status", "diff", "show", "blame",
|
|
39
|
+
"branch", "tag",
|
|
40
|
+
"remote",
|
|
41
|
+
"stash",
|
|
42
|
+
"describe", "shortlog", "rev-parse",
|
|
43
|
+
"ls-files", "ls-remote", "ls-tree",
|
|
44
|
+
"cat-file", "count-objects",
|
|
45
|
+
"help", "version",
|
|
46
|
+
"bisect"
|
|
47
|
+
],
|
|
48
|
+
"_removed_from_git_safe": {
|
|
49
|
+
"config": "git config without --get/--list is a write command (user.email, core.hooksPath, etc.)"
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
"git_unsafe_subcommands": [
|
|
53
|
+
"push", "pull", "fetch",
|
|
54
|
+
"commit", "merge", "rebase", "cherry-pick", "am",
|
|
55
|
+
"reset", "restore", "checkout", "switch",
|
|
56
|
+
"add", "rm", "mv",
|
|
57
|
+
"clean", "gc",
|
|
58
|
+
"init", "clone",
|
|
59
|
+
"submodule", "worktree"
|
|
60
|
+
],
|
|
61
|
+
|
|
62
|
+
"dangerous_flags": [
|
|
63
|
+
"--force", "-f",
|
|
64
|
+
"--delete", "-D",
|
|
65
|
+
"--hard",
|
|
66
|
+
"--no-verify",
|
|
67
|
+
"--allow-unrelated-histories",
|
|
68
|
+
"--squash"
|
|
69
|
+
],
|
|
70
|
+
|
|
71
|
+
"confidence_threshold": 0.5
|
|
72
|
+
}
|